On this page

Skip to content

A Brief Discussion on C# Property Syntax Sugar and the Evolution of NRT Mechanisms

The reason I am writing this is that I recently stumbled upon the field keyword in C# 14. It allows auto-properties to include custom logic, so you no longer have to revert to writing manual backing fields just for a single line of Trim(). I took this opportunity to also organize some other syntax features I recall that improve the NRT experience.

The Evolution of C# Property Syntax

C# properties incorporate the early "Getter/Setter design pattern" directly into the language, using methods to wrap fields. Although they look like fields from the caller's perspective, the definition side used to be quite verbose, leading to the introduction of various syntax sugars to simplify definitions.

C# versions are usually released alongside the .NET SDK. The labels below indicate the version where the syntax first appeared.

1. Early Classical Approach: Backing Field (C# 1.0 / .NET Framework 1.0)

csharp
public class User {
    private string name;
    
    public string Name {
        get { return name; }
        set { name = value; }
    }
}

2. Auto-Implemented Properties (C# 3.0 / .NET Framework 3.5)

In practice, most properties are simply data containers without any logic. Writing private string name; every time is very tedious, so auto-properties were introduced, with the compiler generating the fields under the hood.

After auto-properties were introduced, many beginners during the Web Forms era couldn't distinguish between properties and fields (though when I first learned C#, I was mainly stuck on the semantics and usage timing of properties versus Get methods).

csharp
public class User {
    public string Name { get; set; }
}

3. Property Initializers (C# 6.0 / .NET Framework 4.6)

When using C# 3.0 auto-properties, if you needed to provide an initial value, you had to abandon auto-properties and revert to the C# 1.0 backing field style. To solve this, C# 6.0 allowed assigning initial values directly to auto-properties.

csharp
public class User {
    public string Name { get; set; } = "Default Name";
}

4. Expression-bodied Properties: Lambda Syntax Sugar (C# 6.0, C# 7.0 / .NET Framework 4.6, 4.7)

To solve the issue of deeply nested curly braces { }, C# 6.0 supported the => syntax for read-only properties, and C# 7.0 extended this to both get and set, making the code flatter.

csharp
public class User {
    private string name;

    public string Role => "User";

    public string Name {
        get => name;
        set => name = value.Trim();
    }
}

WARNING

Property initializers and read-only expression-bodied syntax look very similar, and those unfamiliar with them can easily make mistakes. However, they differ completely in memory and execution lifecycle, which can lead to bugs in practice.

  • public string Name => "Default Name" (Expression-bodied): Dynamically calculated. The code is re-executed every time it is called.
  • public string Name { get; } = "Default Name" (Property Initializer): Statically cached. It is executed only once when the object is instantiated (new), and the value remains fixed thereafter.

Disaster scenario example (using Guid):

csharp
public class Order {
    // ❌ Incorrect: A new Guid is generated every time OrderId is read, which causes issues with serialization or log tracking.
    public Guid OrderId => Guid.NewGuid(); 

    // ✅ Correct: Generated only once during new(), state remains unchanged thereafter.
    public Guid CorrectOrderId { get; } = Guid.NewGuid();
}

// Caller
Order order = new();

5. Semi-Auto Properties and the field Keyword (C# 13 Preview, C# 14 / .NET 9, .NET 10)

Despite the syntax sugars above, if you wanted to add a single line of logic inside a set (e.g., Trim() or NotifyPropertyChanged), auto-properties would break immediately. Especially in DDD, where you often add logic checks or data normalization to a set, you had to revert to the C# 1.0 manual private field declaration.

To completely eliminate meaningless field declarations, Microsoft finally introduced the new contextual keyword field, allowing you to directly access the field generated by the compiler.

csharp
public class User {
    public string Name { 
        get;
        set => field = value.Trim();
    }
}

TIP

It is recommended to perform logic processing in set as much as possible, because get is used more frequently and carries some invisible overhead. This also avoids potential issues where Entity Framework Core might bypass get and access the backing field directly.

INFO

C# 12 introduced Primary Constructors to reduce field declarations, but I personally have reservations about it and consider it a controversial design.

Except for when it was first released, I rarely use it unless I want to take a shortcut. To me, it feels more like a product that sacrifices semantic clarity for simplicity. It not only easily induces developers to ignore necessary parameter checks (Guard Clauses) for the sake of laziness, but it also blurs the boundaries between parameters, fields, and properties. If the class logic is slightly complex and not well-written, this implicit capture mechanism makes the object's internal state messy and difficult to identify.

NRT (Nullable Reference Types) and Check Mechanism Completion

NRT was introduced in C# 8.0 with the goal of eliminating NullReferenceException. After all, people often say the biggest failure in programming is null. That said, C# structs have Nullable<T>, allowing value-type structs to represent a null state; therefore, the core of the problem should be whether we can "explicitly identify" the possibility of its existence.

For details on NRT, refer to Nullable reference types. Its main purpose is to allow developers to use ? to annotate reference types, actively declaring "this might be empty," thereby establishing a clear contract.

  • ASP.NET Core's string binding validation will still block null values if [Required] is not added, but it won't block empty strings. So if you hear from someone else's API that they need an empty string when no value is passed, it's basically because of this.
  • Since Entity Framework Core 5.0, for Code First property definitions, if ? is not marked, EF Core expects the database column to be NOT NULL.

To force violations to be uncompilable, you should add <WarningsAsErrors>nullable</WarningsAsErrors>. This setting specifies which warnings become compilation errors. You can also add specific error codes (e.g., async-related CS4014;CS1998) separated by semicolons to force them to be uncompilable XD.

However, I didn't like enabling NRT settings before. Firstly, the main significance of this setting lies in interface contracts; if the team isn't willing to comply, it's just misleading noise, and the maintenance cost is high. I wouldn't want to force others to comply, so it's better to turn it off to avoid seeing a bunch of warnings that are an eyesore. Secondly, at the time, this mechanism had a terrible experience for DTOs (Data Transfer Objects).

Why did I want to turn it off before?

During the C# 8 to C# 10 era, NRT required non-null properties to be initialized. But DTOs are usually assigned by deserialization tools or ORMs. To avoid compiler warnings, one had to write meaningless default values or use ! (null-forgiving) to say there is definitely a value here, don't show a warning:

csharp
public class UserDto {
    // Meaningless code written to trick the compiler
    public string UserName { get; set; } = "";

    public string NickName { get; set; } = null!;

    public string Email { get; set; } = default!; 
}

Why did I dislike this? Setting = "" is generally used when the default is an empty string, but under NRT, it became a means to suppress warnings. And null!, telling the compiler there is a value, but if the set isn't actually triggered, there won't be a value, leading to misjudgment. This forces class definitions to bear promises they cannot guarantee, potentially leading to even harder-to-track issues.

Completion of the Mechanism

Later, I saw the introduction of the following mechanisms, and I felt that the NRT mechanism was finally complete (definitely not because I can ask AI to help handle it).

  • init (C# 9.0): Allows properties to be assigned during object initialization (new() { ... }) and then become read-only, protecting data immutability.
  • required (C# 11.0): It forces the caller to "must" provide a value for the property at the moment of new(). With this guarantee, the compiler no longer forces you to write null! inside the class.
csharp
public class UserDto {
    public required string UserName { get; init; }
}

// Caller must provide a value, otherwise compilation fails
UserDto dto = new() { UserName = "Alice" };

[SetsRequiredMembers]: Resolving Conflicts with Constructors

required must be used in conjunction with [SetsRequiredMembers] because it forces the caller to use curly braces { } to provide values. If you provide a traditional parameterized constructor, the compiler will still show a warning because it only recognizes { }.

At this point, you need to add the [SetsRequiredMembers] attribute to tell the compiler that you have already set all required properties within this constructor.

csharp
using System.Diagnostics.CodeAnalysis;

public class User {
    public required string UserName { get; init; }
    public required string Email { get; init; }

    // With this Attribute, the compiler will no longer require external callers to use { } for initialization
    [SetsRequiredMembers]
    public User(string userName, string email) {
        UserName = userName; 
        Email = email;
    }
}

// Caller
User user = new("Alice", "[email protected]");

In practice, handling NRT also requires using [AllowNull] and [NotNull]. I won't mention them here (because I've forgotten many of them and don't want to organize them in this article for now). For details, refer to Attributes for null-state static analysis.

Application of required in Web API

Beyond improving the NRT experience, when building APIs in the past, we often encountered situations where a struct was clearly required, but to distinguish whether the frontend passed a default value (like 0 or false) or missed the property entirely, we had to use Nullable<T> (this refers to [FromBody]; for the [FromForm] route, there was [BindRequired], but now in most cases, you can consider using required).

Of course, this is fine for me, but some people online have reacted by saying this is a compromise that breaks semantic boundaries. However, now with required, if a property is marked required but is missed by the frontend, a JsonException will be thrown. For details, refer to Required properties.

Postscript

With property syntax sugar evolving to this extent, besides Lazy<T>, I can't think of anything else that forces the addition of field handling. This is why I wanted to take notes when I saw this. I look forward to the day Microsoft adds syntax sugar for Lazy<T> as well.

Now that the NRT mechanism is complete, I am quite willing to use it in personal projects. It's just that with AI Agents, I'm a bit lazier and often ask them to handle it for me. Sometimes I encounter situations where I think the AI Agent knows better how to handle it than I do, but sometimes, whether due to a lack of context or laziness, it takes several attempts to get it right. I feel like I should find time to create a Skill to handle this.

Change Log

  • 2026-03-30 Initial version created.